<?php
/**
 * Module: Define
 * 
 * This module provides functionality for users to create and recall
 * definitions of (a) certain word(s) or abbreviation (called a Define
 * in this module). It features versions (multiple descriptions with 
 * one define), locking of define (so that nobody can change it) and
 * soft-deletion (it appears deleted, but it's still recallable by the
 * bot owner).
 * 
 * Config array for config.php:
 * 
 * 'Define' => array
 * (
 * 	'Triggers' => array
 * 	(
 * 		'Define'       => '.define',
 * 		'Defines'      => '.defines',
 * 		'LockDefine'   => '.lockdefine',
 * 		'UnlockDefine' => '.unlockdefine',
 * 		'SearchDefine' => '.searchdefine',
 * 		'DeleteDefine' => '.deldefine',
 * 	),
 * ),
 * 
 * Author: Mr. Bondt
 * 		   mrbondt@goldenak.eu
 */
class Define extends ModuleBase {
	/**
	 * Property: m_aTriggers
	 * 
	 * This associative array contains all the triggers for this 
	 * module, as configured in config.php.
	 */
	private $m_aTriggers;
	
	/**
	 * Property: m_aDefines
	 * 
	 * This is the main array with all the defines and all of their
	 * versions. Needless to say that it can get monstrous.
	 */
	private $m_aDefines;
	
	/**
	 * Property: m_aDefineLocks
	 * 
	 * A simple array containing the names of the defines which are
	 * locked.
	 */
	private $m_aDefineLocks;
	
	/**
	 * Function: __construct
	 * 
	 * The constructor loads up the configuration (the triggers) and
	 * loads up the defines from Defines.dat.
	 */
	public function __construct () {
		$this -> loadDefines ();
		
		$aConfig = Configuration :: getInstance () -> get ('Define');
		
		if (empty ($aConfig)) {
			throw new Exception ('No configuration found for module "Define". This module will not be available.');
		}
		
		$this -> m_aTriggers = $aConfig ['Triggers'];
	}
	
	/**
	 * Function: __destruct
	 * 
	 * The destructor quickly saves all defines before shutting down 
	 * the bot.
	 */
	public function __destruct () {
		//$this -> saveDefines ();
	}
	
	/**
	 * Function: loadDefines
	 * 
	 * This method will load up all the defines in Defines.dat and loops
	 * through all of them to make sure everything is optimized. This 
	 * includes fixing the versions so that they follow eachother up nicely.
	 * Also, all the keys are made lowercase, for case insensitive support.
	 * After all this has been done, the defines are stored again in Defines.dat.
	 */
	public function loadDefines () {
		if (!file_exists ('Data/Defines.dat')) {
			$this -> m_aDefines = array ();
			$this -> m_aDefineLocks = array ();
		} else {
			$this -> m_aDefines = unserialize (file_get_contents ('Data/Defines.dat'));
			
			foreach ($this -> m_aDefines as $sDefine => $aDefine) {
				$sDefine = strtolower ($sDefine);
				$this -> m_aDefines [$sDefine] = $aDefine;
				
				if (!isset ($this -> m_aDefines [$sDefine]['CurrentVersion'])) {
					$nCurrentVersion = $this -> getTotalVersions ($sDefine);
				} else {
					$nCurrentVersion = $this -> m_aDefines [$sDefine]['CurrentVersion'];
				}
				
				// Make sure the versions follow eachother up nicely.
				$i = 1;
				$aNewDefine = array ();
				foreach ($aDefine as $mKey => $aVoid) {
					if ($mKey == 'CurrentVersion') continue;
					
					$aNewDefine [$i ++] = $aVoid;
				}
				$this -> m_aDefines [$sDefine] = $aNewDefine;
				$this -> m_aDefines [$sDefine]['CurrentVersion'] = $nCurrentVersion;
				
				if ($aDefine [1]['Define'] != $sDefine && isset ($this -> m_aDefines [$aDefine [1]['Define']])) {
					// Remove keys containing capital letters, since everything should be lowercase.
					unset ($this -> m_aDefines [$aDefine [1]['Define']]);
				}
			}
			$this -> m_aDefineLocks = array_map ('strtolower', unserialize (file_get_contents ('Data/DefineLocks.dat')));
		}
		
		$this -> saveDefines ();
	}
	
	/**
	 * Function: saveDefines
	 * 
	 * Nothing fancy around here like in loadDefines(), in stead, just
	 * plain and simple serializing and saving to file.
	 */
	public function saveDefines () {
		file_put_contents ('Data/Defines.dat', serialize ($this -> m_aDefines));
		file_put_contents ('Data/DefineLocks.dat', serialize ($this -> m_aDefineLocks));
	}
	
	/**
	 * Function: addDefine
	 * Argument: sDefine (string) - The word or words to define
	 * Argument: sDescription (string) - The description with these word(s)
	 * Argument: sName (string) - The person who gave the definition
	 * Argument: sChannel (string) - The channel this happened in
	 * 
	 * This method adds a definition to the internal array and returns its
	 * versionnumber. If no number is returned then a boolean is returned. 
	 * In this case, true means that the define has reverted to a certain 
	 * version, and false means that the define already has that description.
	 */
	private function addDefine ($sDefine, $sDescription, $sName = null, $sChannel = null) {
		$sLowerDefine = strtolower ($sDefine);
		
		if (!$this -> isDefined ($sLowerDefine)) {
			$this -> m_aDefines [$sLowerDefine] = array ();
		} else {
			$aDefine = $this -> getDefine ($sLowerDefine);
			if (strtolower ($aDefine ['Description']) == strtolower ($sDescription)) {
				return false;
			}
		}
		
		if ($this -> hasDefine ($sLowerDefine, $sDescription)) {
			$this -> m_aDefines [$sLowerDefine]['CurrentVersion'] = $this -> getVersion ($sLowerDefine, $sDescription);
			return true;
		}
		
		$iVersion = $this -> getTotalVersions ($sDefine) + 1;
		
		// Pay attention, this is the only time you can see the structure.
		$aDefine = array
		(
			'Define'      => $sDefine,
			'Description' => trim ($sDescription),
			'Time'        => time (),
			'Name'        => $sName,
			'Channel'     => $sChannel,
			'Version'     => $iVersion,
			'Deleted'     => false,
		);
		
		$this -> m_aDefines [$sLowerDefine][$iVersion]        = $aDefine;
		$this -> m_aDefines [$sLowerDefine]['CurrentVersion'] = $iVersion;
		
		return $iVersion;
	}
	
	/**
	 * Function: deleteDefine
	 * Argument: sDefine (string) - The define in question
	 * Argument: iVersion (int) - The version to delete (null for all)
	 * 
	 * This method deletes a certain version of a define, or just
	 * all of them, when $iVersion = null. Note that this will not
	 * physically delete, but will only set 'Deleted' in the version
	 * to true.
	 */
	private function deleteDefine ($sDefine, $iVersion = null) {
		$sDefine = strtolower ($sDefine);
		
		if (!$this -> isDefined ($sDefine, $iVersion)) {
			return false;
		}
		
		if ($iVersion === null) {
			foreach ($this -> m_aDefines [$sDefines] as $iVersion => $aDefine) {
				$this -> m_aDefines [$sDefine][$iVersion]['Deleted'] = true;
			}
			$this -> m_aDefines [$sDefine]['CurrentVersion'] = 0;
		} else {
			$this -> m_aDefines [$sDefine][$iVersion]['Deleted'] = true;
			if ($this -> m_aDefines [$sDefine]['CurrentVersion'] == $iVersion) {
				$this -> m_aDefines [$sDefine]['CurrentVersion'] = $this -> getTotalVersions ($sDefine);
			}
		}
		
		return true;
	}
	
	/**
	 * Function: getDefine
	 * Argument: sDefine (string) - The define in question
	 * Argument: iVersion (int) - The version to get (null to get the current)
	 * 
	 * This method will get the associative array with of a define, given
	 * it's not deleted and the version exists.
	 */
	private function getDefine ($sDefine, $iVersion = null) {
		$sDefine = strtolower ($sDefine);
		
		if (!$this -> isDefined ($sDefine)) {
			return false;
		}
		
		if ($iVersion === null) {
			$iVersion = $this -> m_aDefines [$sDefine]['CurrentVersion'];
		}
		
		return $this -> m_aDefines [$sDefine][$iVersion];
	}
	
	/**
	 * Function: getVersion
	 * Argument: sDefine (string) - The define in question
	 * Argument: sDescription (string) - The description associated with a certain version
	 * 
	 * This method will loop through all versions of a define and
	 * do a case insensitive comparison to get the version number 
	 * belonging to the given description. If nothing is found, false
	 * is returned.
	 */
	private function getVersion ($sDefine, $sDescription) {
		$sDefine = strtolower ($sDefine);
		
		foreach ($this -> m_aDefines [$sDefine] as $iVersion => $aDefine) {
			if (strtolower ($aDefine ['Description']) == strtolower (trim ($sDescription))) {
				return $iVersion;
			}
		}
		
		return false;
	}
	
	/**
	 * Function: getTotalVersions
	 * Argument: sDefine (string) - The define in question
	 * 
	 * This method will return the total number of versions with the
	 * given define. This uses a loop because there may be deleted
	 * versions in there too.
	 */
	private function getTotalVersions ($sDefine) {
		$sDefine = strtolower ($sDefine);
		$nCount  = 0;
		
		if (!$this -> isDefined ($sDefine)) {
			return false;
		}
		
		foreach ($this -> m_aDefines [$sDefine] as $sKey => $aDefine) {
			if ($sKey == 'CurrentVersion') continue;
			
			if ($aDefine ['Deleted']) continue;
			
			$nCount ++;
		}
		
		return $nCount;
	}
	
	/**
	 * Function: hasDefine
	 * Argument: sDefine (string) - The define in question
	 * Argument: sDescription (string) - The description to look for
	 * 
	 * This method checks if the given description already exists in a
	 * stored version. This includes deleted versions.
	 */
	private function hasDefine ($sDefine, $sDescription) {
		$sDefine = strtolower ($sDefine);
		
		foreach ($this -> m_aDefines [$sDefine] as $iVersion => $aDefine) {
			if (strtolower ($aDefine ['Description']) == strtolower (trim ($sDescription))) {
				return true;
			}
		}
		
		return false;
	}
	
	/**
	 * Function: isDefined
	 * Argument: sDefine (string) - The define in question
	 * Argument: iVersion (int) - The version to check (null for none)
	 * 
	 * This method checks whether the given define exists and is not deleted.
	 * If $iVersion = null, then it checks if there's a version that's not
	 * deleted.
	 */
	private function isDefined ($sDefine, $iVersion = null) {
		$sDefine = strtolower ($sDefine);
		
		if ($iVersion === null) {
			if (isset ($this -> m_aDefines [$sDefine])) {
				foreach ($this -> m_aDefines [$sDefine] as $iVersion => $aDefine) {
					if (!$this -> m_aDefines [$sDefine][$iVersion]['Deleted']) return true;
				}
			}
			return false;
		} else {
			return isset ($this -> m_aDefines [$sDefine][$iVersion]) && !$this -> m_aDefines [$sDefine][$iVersion]['Deleted'];
		}
	}
	
	/**
	 * Function: isDefineLocked
	 * Argument: sDefine (string) - The define in question
	 * 
	 * This method returns a boolean which'll tell if the given define is 
	 * locked or not.
	 */
	private function isDefineLocked ($sDefine) {
		return in_array (strtolower ($sDefine), $this -> m_aDefineLocks);
	}
	
	/**
	 * Function: lockDefine
	 * Argument: sDefine (string) - The define to lock
	 * 
	 * This method will lock the given define. It does not check if the define
	 * has a version, so you can use this too to keep certain defines empty.
	 */
	private function lockDefine ($sDefine) {
		$sDefine = strtolower ($sDefine);
		
		if (!$this -> isDefineLocked ($sDefine)) {
			$this -> m_aDefineLocks [] = $sDefine;
			return true;
		} else {
			return false;
		}
	}
	
	/**
	 * Function: unlockDefine
	 * Argument: sDefine (string) - The define to unlock
	 * 
	 * This method will unlock a locked define.
	 */
	private function unlockDefine ($sDefine) {
		$sDefine = strtolower ($sDefine);
		
		if (!$this -> isDefineLocked ($sDefine)) {
			return false;
		} else {
			unset ($this -> m_aDefineLocks [array_search ($sDefine, $this -> m_aDefineLocks)]);
			return true;
		}
	}
	
	/**
	 * Function: searchDefine
	 * Argument: sSearch (string) - The string to search for
	 * 
	 * This method loops through all defines and looks for matches in both
	 * the define as well as the description. It returns an indexed array
	 * with direct define matches first and description matches (with a 
	 * version number after the define) after them.
	 */
	public function searchDefine ($sSearch) {
		$aDefineResult = array ();
		$aDescResult   = array ();
		
		foreach ($this -> m_aDefines as $sDefine => $aVersions) {
			if (stripos ($sDefine, $sSearch) !== false) {
				$aDefineResult [] = $aVersions [1]['Define'];
			}
			
			foreach ($aVersions as $mKey => $aDefine) {
				if ($mKey == 'CurrentVersion') continue;
				
				if ($aDefine ['Deleted']) continue;
				
				if (stripos ($aDefine ['Description'], $sSearch) !== false) {
					$aDescResult [] = $aDefine ['Define'] . ' (v' . $mKey . ')';
				}
			}
		}
		
		return array_merge ($aDefineResult, $aDescResult);
	}
	
	/**
	 * Function: handleCommand
	 * Argument: pBot (Bot) - The bot that received the message
	 * Argument: sMessage (string) - The whole message containing the command
	 * Argument: sNickname (string) - The person who issued the command
	 * Argument: sChannel (string) - The channel all this happened in
	 * 
	 * This method is the real deal. Here's the user's commands get processed and
	 * the various define methods get called. It's pretty large, but it's also 
	 * pretty self-explanatory.
	 */
	private function handleCommand (Bot $pBot, $sMessage, $sNickname, $sChannel) {
		$sParams  = null;
		$sOptions = '';
		
		if (strpos ($sMessage, ' ') === false) {
			$sTrigger = $sMessage;
		} else {
			list ($sTrigger, $sParams) = explode (' ', $sMessage, 2);
			$sParams = trim ($sParams);
		}
		
		if (strpos ($sTrigger, '-') !== false) {
			list ($sTrigger, $sOptions) = explode ('-', $sTrigger, 2);
		}
		$aOptions = $this -> parseOptions ($sOptions);
		
		switch ($sTrigger) {
			/**
			 * Command: .define
			 * Adds, updates or retrieves defines.
			 */
			case $this -> m_aTriggers ['Define']:
				if ($sParams === null) {
					$sMsg = '3Usage: ' . $sTrigger . ' Word(s) [= Description]';
				} else if (strpos ($sParams, '=') !== false) {
					// Update
					list ($sDefine, $sDescription) = explode ('=', $sParams, 2);
					$sDefine = Util :: stripFormat (rtrim ($sDefine));
					$sDescription = ltrim ($sDescription);
					
					if ($this -> isDefineLocked ($sDefine)) {
						$sMsg = 'Define "' . $sDefine . '" is locked, no more descriptions can be added.';
					} else {
						$mResult = $this -> addDefine ($sDefine, $sDescription, $sNickname, $sChannel);
						
						if ($mResult === 1) {
							$sMsg = 'Define for "' . $sDefine . '" added.';
						} else if ($mResult === false) {
							$sMsg = 'Define for "' . $sDefine . '" already has that description, nothing changed.';
						} else if ($mResult === true) {
							$sMsg = 'Reverted define "' . $sDefine . '" to version ' . $this -> getVersion ($sDefine, $sDescription) . '.';
						} else {
							$sMsg = 'Define "' . $sDefine . '" updated.';
						}
					
						$this -> saveDefines ();
					}
				} else {
					// Retrieve
					if (!$this -> isDefined ($sParams, $aOptions ['v'])) {
						if ($aOptions ['v'] === null) {
							$sMsg = 'No define found for "' . $sParams . '".';
						} else {
							$sMsg = 'Version ' . $aOptions ['v'] . ' doesn\'t exist for define "' . $sParams . '".';
						}
					} else {
						$aDefine = $this -> getDefine ($sParams, $aOptions ['v']);
						$sMsg  = '' . $aDefine ['Define'] . ' = ' . $aDefine ['Description'] . ' (';
						
						$nTotalVersions = $this -> getTotalVersions ($sParams);
						if ($aOptions ['v'] !== null || 
							($nTotalVersions > 1 && $aDefine ['Version'] != $nTotalVersions)) 
						{
							$sMsg .= 'version ' . $aDefine ['Version'] . ' ';
						}
						
						if ($aDefine ['Name'] != null) {
							$sMsg .= 'by ' . $aDefine ['Name'] . ' @ ';
						}
						
						$sMsg .= date ('D, j M y - H:i:s', $aDefine ['Time']);
						
						
						if ($nTotalVersions > 1) {
							$sMsg .= ', ' . $nTotalVersions . ' versions';
						}
						
						if ($this -> isDefineLocked ($sParams)) {
							$sMsg .= ', locked';
						}
						
						$sMsg .= ')';
					}
				}
				break;
				
			/**
			 * Command: .defines
			 * Counts the number of stored defines.
			 */
			case $this -> m_aTriggers ['Defines']:
				$sMsg = 'There are currently ' . count ($this -> m_aDefines) . ' defines stored.';
				break;
				
			/**
			 * Command: .lockdefine
			 * Locks a define.
			 */
			case $this -> m_aTriggers ['LockDefine']:
				$pEval = ModuleManager :: getInstance () -> offsetGet ('Evaluation');
				if (!$pEval -> checkSecurity ($pBot, 9999)) return;
			
				if ($sParams === null) {
					$sMsg = '3Usage: ' . $sTrigger . ' Word(s)';
				} else if ($this -> lockDefine ($sParams)) {
					$sMsg = 'Define "' . $sParams . '" locked.';
					$this -> saveDefines ();
				} else {
					$sMsg = 'Define "' . $sParams . '" already locked.';
				}
				break;
				
			/**
			 * Command: .unlockdefine
			 * Unlocks a define.
			 */
			case $this -> m_aTriggers ['UnlockDefine']:
				$pEval = ModuleManager :: getInstance () -> offsetGet ('Evaluation');
				if (!$pEval -> checkSecurity ($pBot, 9999)) return;
			
				if ($sParams === null) {
					$sMsg = '3Usage: ' . $sTrigger . ' Word(s)';
				} else if ($this -> unlockDefine ($sParams)) {
					$sMsg = 'Define "' . $sParams . '" unlocked.';
					$this -> saveDefines ();
				} else {
					$sMsg = 'Define "' . $sParams . '" already unlocked.';
				}
				break;
				
			/**
			 * Command: .searchdefine
			 * Searches all the define for a pattern.
			 */
			case $this -> m_aTriggers ['SearchDefine']:
				if ($sParams === null) {
					$sMsg = '3Usage: ' . $sTrigger . ' Word(s)';
				} else {
					$aResult = $this -> searchDefine ($sParams);
					if (count ($aResult) == 0) {
						$sMsg = 'No results found for "' . $sParams . '".';
					} else {
						$sMsg = '3Results (' . count ($aResult) . '): ' . implode (', ', $aResult);
					}
				}
				break;
				
			/**
			 * Command: .deletedefine
			 * Deletes a certain version of a define.
			 */
			case $this -> m_aTriggers ['DeleteDefine']:
				$pEval = ModuleManager :: getInstance () -> offsetGet ('Evaluation');
				if (!$pEval -> checkSecurity ($pBot, 9999)) return;
				
				if ($sParams !== null) {
					$aParams = explode (' ', $sParams, 2);
				}
				
				if ($sParams === null || count ($aParams) < 2) {
					$sMsg = '3Usage: ' . $sTrigger . ' Version Define';
					break;
				}
				
				list ($sVersion, $sDefine) = $aParams;
				if ($sVersion [0] == 'v') {
					$iVersion = (int) substr ($sVersion, 1);
				} else {
					$iVersion = (int) $sVersion;
				}
				
				if (!$this -> isDefined ($sDefine, $iVersion)) {
					$sMsg = 'No define found.';
				} else {
					$this -> deleteDefine ($sDefine, $iVersion);
					$this -> saveDefines ();
					$sMsg = 'Version ' . $iVersion . ' of define "' . $sDefine . '" deleted.';
				}
				break;
		}
		
		if (isset ($sMsg)) {
			if ($sChannel == null) {
				$sDest = $sNickname;
			} else {
				$sDest = $sChannel;
			}
			
			$pBot -> send ('PRIVMSG ' . $sDest . ' :' . $sMsg);
		}
	}
	
	/**
	 * Function: parseOptions
	 * Argument: sOptions (string) - The string with the given options
	 * 
	 * This method parses the options given in the trigger for .define.
	 * This way, other versions can be shown.
	 */
	private function parseOptions ($sOptions) {
		$aOutput = array ('v' => null);
		
		$nLen = strlen ($sOptions);
		$sActive = '';
		for ($i = 0; $i < $nLen; $i++) {
			switch ($sActive) {
				case 'v':
					if (!is_numeric ($sOptions [$i])) {
						$sActive = '';
					} else {
						$aOutput [$sActive] .= $sOptions [$i];
					}
					break;
				
				default:
				case '':
					$sActive = $sOptions [$i];
					break;
			}
		}
		
		return $aOutput;
	}
	
	/**
	 * Function: onConnect
	 * Argument: pBot (Bot) - The bot in question (not used)
	 * 
	 * The onConnect() callback checks if the needed dependencies are available.
	 */
	public function onConnect (Bot $pBot) {
		if (!class_exists ('Func')) {
			echo 'Module "Func" not loaded. The module "Define" requires this module.' . PHP_EOL;
			echo 'Therefore the module "Define" will not be available.' . PHP_EOL;
			
			ModuleManager :: getInstance () -> unloadModule ('Define');
		}
	}
	
	/**
	 * Function: onChannelPrivmsg
	 * Argument: pBot (Bot) - The bot who received the public channel message
	 * Argument: sChannel (string) - Channel in which we received the message
	 * Argument: sNickname (string) - The nickname associated with this message
	 * Argument: sMessage (string) - And of course the actual message we received
	 * 
	 * Enable the commands in channels.
	 */
	public function onChannelPrivmsg (Bot $pBot, $sChannel, $sNickname, $sMessage) {
		$this -> handleCommand ($pBot, $sMessage, $sNickname, $sChannel);
	}
	
	/**
	 * Function: onPrivmsg
	 * Argument: pBot (Bot) - The bot who received the public channel message
	 * Argument: sNickname (string) - The nickname associated with this message
	 * Argument: sMessage (string) - And of course the actual message we received
	 * 
	 * Enable the commands in private messages.
	 */
	public function onPrivmsg (Bot $pBot, $sNickname, $sMessage) {
		$this -> handleCommand ($pBot, $sMessage, $sNickname, $pBot ['Nickname']);
	}
	
	/**
	 * Function: onNotice
	 * Argument: pBot (Bot) - The bot who received the public channel message
	 * Argument: sChannel (string) - Channel in which we received the message
	 * Argument: sNickname (string) - The nickname associated with this message
	 * Argument: sMessage (string) - And of course the actual message we received
	 * 
	 * Enable the commands in notices.
	 */
	public function onNotice (Bot $pBot, $sChannel, $sNickname, $sMessage) {
		$this -> handleCommand ($pBot, $sMessage, $sNickname, $sChannel);
	}
}
?>